<!doctype html>
<html lang="en">
<head>
<title>Webcam monitor</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
    box-sizing: border-box;
}
body {
    font-family: sans-serif;
    line-height: 1.15;
    margin: 0;
}
header {
    font-size: 200%;
    margin: 10px;
}
.content {
    display: table;
    text-align: left;
}
.center {
    text-align: center;
    margin: 0 auto;
}
.label {
    float: left;
    padding: .25rem;
    text-align: right;
    width: 50%;
}
.value {
    float: left;
    padding: .25rem;
    text-align: left;
    width: 50%;
}
.button {
    opacity: 1;
    font-size: .875rem;
    margin-bottom: .5rem;
    padding-left: 1rem;
    padding-right: 1rem;
    padding-top: .5rem;
    padding-bottom: .5rem;
    background-color: #000;
    color: #fff;
    text-decoration: none;
    display: inline-block;
    border-radius: .25rem;
}
.hidden {
    display: none;
}
.modal {
    display: none;
    position: fixed;
    z-index: 1;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    overflow: auto;
    background-color: rgb(0, 0, 0);  /* Fallback if alpha not supported */
    background-color: rgba(0, 0, 0, 0.4);
}
.modal-content {
    background-color: white;
    text-align: center;
    margin: 15% auto;
    padding: 20px;
    border: 1px solid black;
    width: 20%;
}
#help {
    width: 600px;
    display: none;
}
</style>
<script>
(function() {
    'use strict';
    var width = 320;
    var height = 0; // This will be computed based on the input stream

    var streaming = false;
    var video = null;
    var canvas = null;
    var photo = null;
    var countDownDiv = null;
    var monitorButton = null;
    var previewButton = null;
    var imageChange = null;
    var pictureIntervalId = null;
    var countDownIntervalId = null;
    var countDown = 0;
    var sendSignals = false;
    var referenceImage = null;
    var differenceThreshold = 5;

    function startUp() {
        video = document.getElementById('video');
        canvas = document.getElementById('canvas');
        photo = document.getElementById('photo');
        monitorButton = document.getElementById('toggle-monitoring');
        previewButton = document.getElementById('start-preview');
        countDownDiv = document.getElementById('count-down');
        imageChange = document.getElementById('image-change');

        if (window.location.protocol == 'file:') {
            var infoP = document.getElementById('info');
            infoP.appendChild(
                document.createTextNode(
                    'This file needs to be served over HTTPS to access the webcam.'));
            infoP.appendChild(document.createElement('br'));
            infoP.appendChild(
                document.createTextNode(
                    'Please run `python host_files.py` and visit '));
            var linkElement = document.createElement('a');
            var url = 'https://localhost:4443/watch.html';
            linkElement.appendChild(document.createTextNode(url));
            linkElement.title = url;
            linkElement.href = url;
            infoP.appendChild(linkElement);
            infoP.appendChild(document.createElement('br'));
            var certificateInstructions = 'You will need to accept the self-signed certificate by '
            // If Firefox
            if (typeof InstallTrigger !== 'undefined') {
                certificateInstructions += 'clicking "Advanced", "Add Exception", and "Confirm Security Exception".';
            } else {
                certificateInstructions += 'clicking "Advanced" and "Proceed to localhost (unsafe)".';
            }
            infoP.appendChild(document.createTextNode(certificateInstructions));

            infoP.classList.remove('hidden');
            return;
        }

        video.addEventListener('canplay', function(event) {
            if (!streaming) {
                height = video.videoHeight / (video.videoWidth / width);

                // Work around Firefox bug
                if (isNaN(height)) {
                    height = width / (4 / 3);
                }

                video.setAttribute('width', width);
                video.setAttribute('height', height);
                canvas.setAttribute('width', width);
                canvas.setAttribute('height', height);
                streaming = true;
            }
        }, false);

        enumerateDevices();

        monitorButton.addEventListener('click', function(event) {
            event.preventDefault();
            toggleMonitoring();
        }, false);
        previewButton.addEventListener('click', function(event) {
            event.preventDefault();
            startPreview();
        }, false);
        document.getElementById('help-header').addEventListener('click', function(event) {
            document.getElementById('help').style.display = 'block';
        });

        clearPhoto();

        if (document.getElementById('useconds').value === '') {
            console.log("Setting defaults");
            setCommand(commandIterator().next().value);
        }
    }

    function alertError(error) {
        alert('An error occurred: ' + error);
    }

    function toggleMonitoring() {
        sendSignals = !sendSignals;
        if (sendSignals) {
            monitorButton.text = 'Stop monitoring';
            countDown = 5;
            countDownDiv.children[0].children[0].textContent = countDown + '...';
            countDownDiv.style.display = 'block';
            countDownIntervalId = setInterval(updateCountDown, 1000);
            var infoP = document.querySelector('#info');
            while (infoP.hasChildNodes()) {
                infoP.removeChild(infoP.lastChild);
            }
        } else {
            monitorButton.text = 'Start monitoring';
            countDown = 0;
            countDownDiv.style.display = 'none';
            if (countDownIntervalId) {
                clearInterval(countDownIntervalId);
            }
        }
        referenceImage = null;
    }

    function clearPhoto() {
        var context = canvas.getContext('2d');
        context.fillStyle = '#AAA';
        context.fillRect(0, 0, canvas.width, canvas.height);

        var data = canvas.toDataURL('image/png');
        photo.setAttribute('src', data);
    }

    function takePicture() {
        var context = canvas.getContext('2d');
        var rawData;
        if (width && height) {
            canvas.width = width;
            canvas.height = height;
            context.drawImage(video, 0, 0, width, height);
            rawData = context.getImageData(0, 0, width, height);
            context.putImageData(rawData, 0, 0);
            var data = canvas.toDataURL('image/png');
            photo.setAttribute('src', data);
        } else {
            clearPhoto();
        }
    }

    function commandKey(command) {
        return JSON.stringify(
            {
                u: parseInt(command.uSeconds),
                s: parseInt(command.syncRepeats),
                m: parseInt(command.syncMultiplier),
                sr: parseInt(command.signalRepeats),
            }
        )
    }

    function* commandIterator() {
        // Do the most common ones first
        let signal = {
            uSeconds: 400,
            syncRepeats: 4,
            syncMultiplier: 3,
            signalRepeats: 10
        };
        let used = new Set();

        // This first part is what I've observed from RC cars
        for (let uSeconds of [400, 500]) {
            signal.uSeconds = uSeconds;
            for (let syncRepeats of [4]) {
                signal.syncRepeats = syncRepeats;
                for (let syncMultiplier of [3]) {
                    signal.syncMultiplier = syncMultiplier;
                    for (let signalRepeats = 10; signalRepeats <= 30; ++signalRepeats) {
                        signal.signalRepeats = signalRepeats;
                        used.add(commandKey(signal));
                        yield signal;
                    }
                }
            }
        }

        // I guess open it up to more syncRepeats and syncMultipliers
        for (let uSeconds of [400, 500]) {
            signal.uSeconds = uSeconds;
            for (let syncRepeats of [3, 5]) {
                signal.syncRepeats = syncRepeats;
                for (let syncMultiplier of [4, 5]) {
                    signal.syncMultiplier = syncMultiplier;
                    for (let signalRepeats = 10; signalRepeats <= 30; ++signalRepeats) {
                        signal.signalRepeats = signalRepeats;
                        used.add(commandKey(signal));
                        yield signal;
                    }
                }
            }
        }

        // Expand the signal repeats
        for (let uSeconds of [400, 500]) {
            signal.uSeconds = uSeconds;
            for (let syncRepeats of [3, 4, 5]) {
                signal.syncRepeats = syncRepeats;
                for (let syncMultiplier of [3, 4, 5]) {
                    signal.syncMultiplier = syncMultiplier;
                    for (let signalRepeats = 10; signalRepeats <= 65; ++signalRepeats) {
                        signal.signalRepeats = signalRepeats;
                        const key = commandKey(signal);
                        if (used.has(key)) {
                            continue;
                        } else {
                            used.add(key);
                            yield signal;
                        }
                    }
                }
            }
        }

        // Just do everything
        for (let uSeconds = 100; uSeconds < 1201; uSeconds += 100) {
            signal.uSeconds = uSeconds;
            for (let syncRepeats = 2; syncRepeats < 8; ++syncRepeats) {
                signal.syncRepeats = syncRepeats;
                for (let syncMultiplier = 2; syncMultiplier < 8; ++syncMultiplier) {
                    signal.syncMultiplier = syncMultiplier;
                    for (let signalRepeats = 10; signalRepeats <= 65; ++signalRepeats) {
                        signal.signalRepeats = signalRepeats;
                        const key = commandKey(signal);
                        if (used.has(key)) {
                            continue;
                        } else {
                            yield signal;
                        }
                    }
                }
            }
        }
    }

    function setCommand(command) {
        document.getElementById('useconds').value = command.uSeconds;
        document.getElementById('sync-multiplier').value = command.syncMultiplier;
        document.getElementById('sync-repeats').value = command.syncRepeats;
        document.getElementById('signal-repeats').value = command.signalRepeats;
    }

    function sendCommand(commandObject) {
        const frequency = parseFloat(document.getElementById('frequency').value);
        const deadFrequency = frequency < 28 ? 49.860 : 27.095;
        const command = [
            {
                frequency: frequency,
                dead_frequency: deadFrequency,
                burst_us: commandObject.uSeconds * commandObject.syncMultiplier,
                spacing_us: commandObject.uSeconds,
                repeats: commandObject.syncRepeats,
            },
            {
                frequency: frequency,
                dead_frequency: deadFrequency,
                burst_us: commandObject.uSeconds,
                spacing_us: commandObject.uSeconds,
                repeats: commandObject.signalRepeats,
            }
        ];

        var request = new XMLHttpRequest();
        request.onerror = function() {
            alert('Unable to contact the Python server: is host_files.py running?');
        };
        request.onreadystatechange = createReadyStateChangeCallback(request);
        request.open('POST', window.location.origin + '/command/');
        request.setRequestHeader('Content-Type', 'application/json');
        request.send(JSON.stringify(command));
    }

    function createReadyStateChangeCallback(request) {
        return function() {
            if (request.readyState === XMLHttpRequest.DONE) {
                // Any success status code is fine
                if (!('' + request.status).startsWith('2')) {
                    var message;
                    if (request.status === 500) {
                        message = 'Unable to contact the server: is pi_pcm running?';
                    } else {
                        message = request.statusText || 'Unknown error';
                    }
                    alert('Error sending command: ' + message);
                    if (pictureIntervalId) {
                        toggleMonitoring();
                        clearInterval(pictureIntervalId);
                        pictureIntervalId = null;
                    }
                }
            }
        }
    };

    // This function should only be called once
    function enumerateDevices() {
        function removeCameraSelect() {
            var container = document.getElementById('camera-container');
            while (container.firstChild) {
                container.removeChild(container.firstChild);
            }
        }
        if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
            navigator.mediaDevices.enumerateDevices().then(function(devices) {
                devices = devices.filter(function(device) {
                    return device.kind === 'videoinput';
                });
                // If there's only one camera, just use it
                if (devices.length <= 1) {
                    removeCameraSelect();
                    startPreview();
                    return;
                }
                var cameraSelect = document.getElementById('camera-select');
                devices.forEach(function(device, index) {
                    var option = document.createElement('option');
                    // Testing on Firefox on a laptop didn't have any labels
                    if (device.label) {
                        option.text = device.label;
                    } else {
                        option.text = 'Camera ' + (index + 1);
                    }
                    option.value = device.deviceId;
                    cameraSelect.appendChild(option);
                });
            });
        } else {
            // Just hide the camera options
            removeCameraSelect();
            startPreview();
        }
    }

    function getVideoDeviceSelector() {
        var cameraSelect = document.getElementById('camera-select');
        var defaultOptions = {
            width: {
                max: 1280,
                ideal: 640
            },
            height: {
                max: 960,
                ideal: 480
            }
        };
        if (cameraSelect) {
            defaultOptions.deviceId = cameraSelect.value;
            return defaultOptions;
        }
        // Otherwise just grab any device
        return defaultOptions;
    }

    function startPreview() {
        // I'd prefer to always use the environment facing camera. Firefox
        // honors facingMode, iOS Safari doesn't support WebRTC at all, Android
        // Chrome claims to but doesn't. Not sure about Opera. We'll just let
        // the user choose an enumerated device, or use the only one.
        var videoOptions = {
            video: getVideoDeviceSelector(),
            audio: false
        };

        function setVideoSource(stream) {
            if (navigator.mozGetUserMedia) {
                video.mozSrcObject = stream;
            } else {
                video.srcObject = stream;
            }
            video.play();
        }
        if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
            navigator.mediaDevices.getUserMedia(videoOptions).then(setVideoSource).catch(alertError);
        } else {
            var getUserMedia = (
                navigator.getUserMedia ||
                navigator.webkitGetUserMedia ||
                navigator.mozGetUserMedia ||
                navigator.msGetUserMedia);
            if (getUserMedia === undefined) {
                alert('This browser does not support video');
            } else {
                getUserMedia(videoOptions, setVideoSource, alertError);
            }
        }
        pictureIntervalId = setInterval(takePicture, 1000);
    }

    function updateCountDown() {
        countDown -= 1;
        if (countDown <= 0) {
            countDownDiv.style.display = 'none';
            if (countDownIntervalId) {
                clearInterval(countDownIntervalId);
                countDownIntervalId = null;
            }
            if (pictureIntervalId) {
                clearInterval(pictureIntervalId);
                pictureIntervalId = null;
            }
            // Save the reference image
            takePicture();
            var context = canvas.getContext('2d');
            var pixels = context.getImageData(0, 0, canvas.width, canvas.height);
            referenceImage = pixels;
            imageChange.value = 0.0;

            let iterator_ = commandIterator();
            // If we've entered some values, then pick up where we left off
            const startCommandKey = commandKey({
                uSeconds: document.getElementById('useconds').value,
                syncRepeats: document.getElementById('sync-repeats').value,
                syncMultiplier: document.getElementById('sync-multiplier').value,
                signalRepeats: document.getElementById('signal-repeats').value,
            });
            let skippedCount = 0;
            let next_ = iterator_.next();
            while (!next_.done && commandKey(next_.value) !== startCommandKey) {
                ++skippedCount;
                next_ = iterator_.next();
            }
            if (next_.done) {
                console.log("Skipped too many commands, resetting");
                iterator_ = commandIterator();
            } else if (skippedCount > 0) {
                console.log("Skipped " + skippedCount + " commands");
            }

            // Use a recursive function with setTimeout instead of setInterval
            // to work around slow devices calculating diff
            (function loop() {
                takePicture();
                var context = canvas.getContext('2d');
                var rawData = context.getImageData(0, 0, width, height);
                var difference = percentDifference(rawData, referenceImage);
                imageChange.value = (difference + '').substring(0, 6);
                next_ = iterator_.next();
                if (next_.done) {
                    return;
                }
                setCommand(next_.value);
                const frequency = parseFloat(document.getElementById('frequency').value);
                if (frequency > 250) {
                    alert("pi-rc only supports frequencies below 250 MHz");
                    return;
                }
                sendCommand(next_.value);

                pictureIntervalId = setTimeout(
                    function() {
                        if (imageChange.value < differenceThreshold) {
                            loop();
                        } else {
                            toggleMonitoring();
                            saveConfig(next_.value);
                            var infoP = document.getElementById('info');
                            while (infoP.childNodes.length > 0) {
                                infoP.removeChild(infoP.childNodes[0]);
                            }
                            infoP.appendChild(
                                document.createTextNode(
                                    'Movement detected, parameters saved. Visit '));
                            var linkElement = document.createElement('a');
                            var url = '/control.html';
                            linkElement.appendChild(document.createTextNode('control.html'));
                            linkElement.title = url;
                            linkElement.href = url;
                            infoP.appendChild(linkElement);
                            infoP.appendChild(
                                document.createTextNode(
                                    ' to set the individual commands.'));
                            infoP.classList.remove('hidden');
                        }
                    },
                    1000
                );
            })();
        } else {
            countDownDiv.children[0].children[0].textContent = countDown + '...';
        }
    }

    function percentDifference(pixels1, pixels2) {
        // This happens when we stop monitoring, just ignore it
        if (pixels2 === null) {
            return 0.0;
        }
        // On mobile, it looks like the preview is cutting off some pixels, and
        // for some reason, the reference image is different sized from the
        // actual photo, so be careful when iterating
        var pixelCount = Math.min(pixels1.width * pixels1.height, pixels2.width * pixels2.height);
        var limit = pixelCount * 4;
        var sumDifference = 0.0;
        for (var i = 0; i < limit; i += 4) {
            sumDifference += Math.abs(pixels1.data[i] - pixels2.data[i]);
            sumDifference += Math.abs(pixels1.data[i + 1] - pixels2.data[i + 1]);
            sumDifference += Math.abs(pixels1.data[i + 2] - pixels2.data[i + 2]);
            // Skip alpha
        }
        return sumDifference / (pixelCount * 256 * 3) * 100;
    }

    function saveConfig(config) {
        const fullConfig = {
            frequency: parseFloat(document.getElementById('frequency').value),
            synchronization_burst_us: config.uSeconds * config.syncMultiplier,
            synchronization_spacing_us: config.uSeconds,
            total_synchronizations: config.syncRepeats,
            signal_burst_us: config.uSeconds,
            signal_spacing_us: config.uSeconds,
        };
        var request = new XMLHttpRequest();
        request.open('POST', window.location.origin + '/save/');
        request.setRequestHeader('Content-Type', 'application/json');
        request.send(JSON.stringify(fullConfig));
    }

    window.addEventListener('load', startUp, false);
})();
</script>
</head>
<body>
<div id="count-down" class="modal">
    <div class="modal-content">
        <p>5...</p>
    </div>
</div>
<div class="content center">
    <header>RC monitor</header>
    <div class="hidden">
        <video id="video">Sorry, no video stream is available</video>
        <canvas id="canvas"></canvas>
    </div>
    <div class="center">
        <img id="photo" alt="The screen capture that will appear in this box.">
    </div>
    <p id="info" class="hidden"></p>
</div>
<div>
    <div id="camera-container">
        <div class="label">
            <label for="camera-select">Camera</label>
        </div>
        <div class="value">
            <select id="camera-select">
            </select>
        </div>
        <div class="content center">
            <a id="start-preview" class="button">Start camera preview</a>
        </div>
    </div>
    <div class="label">
        <label for="frequency">Frequency MHz</label>
    </div>
    <div class="value">
        <select id="frequency">
            <option value="26.995">26.995</option>
            <option value="27.045">27.045</option>
            <option value="27.095">27.095</option>
            <option value="27.145">27.145</option>
            <option value="27.195">27.195</option>
            <option value="27.255">27.255</option>
            <option value="49.830">49.830</option>
            <option value="49.845">49.845</option>
            <option value="49.860">49.860</option>
            <option value="49.875">49.875</option>
            <option value="49.890">49.890</option>
        </select>
        <a id="other-frequency"
            href="#"
            style="font-size: x-small;"
            onclick="
                var el = document.getElementById('frequency');
                var oldValue = el.value;
                var par = el.parentNode;
                par.removeChild(el);
                el = document.createElement('input');
                el.setAttribute('id', 'frequency');
                el.value = oldValue;
                par.appendChild(el);
                el = document.getElementById('other-frequency');
                el.parentNode.removeChild(el);
        ">
            Manually enter frequency
        </a>
    </div>
    <div class="label">
        <label for="useconds">Useconds</label>
    </div>
    <div class="value">
        <input id="useconds" class="parameter" type="number" step="100">
    </div>
    <div class="label">
        <label for="sync-repeats">Sync repeats</label>
    </div>
    <div class="value">
        <input id="sync-repeats" class="parameter" type="number" step="1">
    </div>
    <div class="label">
        <label for="sync-multiplier">Sync multiplier</label>
    </div>
    <div class="value">
        <input id="sync-multiplier" class="parameter" type="number" step="1">
    </div>
    <div class="label">
        <label for="signal-repeats">Signal repeats</label>
    </div>
    <div class="value">
        <input id="signal-repeats" class="parameter" type="number" step="1">
    </div>
    <div class="label">
        <label for="image-change">Image change %</label>
    </div>
    <div class="value">
        <input id="image-change" class="parameter" disabled="disabled" value="0.0">
    </div>
</div>
<div class="content center">
    <a id="toggle-monitoring" class="button">Start monitoring</a>
</div>
<p id="help-header" class="center">Help &#x25BC;</p>
<div id="help" class="center">
    <p>
This page will help you search for command codes for your RC car. It does so by
repeatedly broadcasting RC commands and using a webcam to see if the car moves.
Once the car does move, the parameters will be saved and you will be redirected
to the control page where you can test and drive the car interactively.
    </p>
    <p>
pi-rc only works with simple cars, such as those that can only move forward and
reverse and left and right. More complicated controls, such as ones that have
proportional steering or throttle, will likely not work.
    </p>
    <p>
To start, run <code>pi_pcm</code> on the Raspberry Pi. Set the frequency of the
in the above "Frequency MHz" selector. In the United States, most cheap RC cars
run in either the 27 MHz or the 49 MHz band. This should be labelled on the car
itself. If you the car doesn't list the exact frequency (such as 26.995 MHz),
then select the middle frequency in that band (e.g. 27.095 for 27 MHz). Then,
place the car in front of the webcam in a location with consistent lighting,
i.e. away from sunlight. Then click "Start Monitoring". Once the car moves, the
parameters will be saved and you will be directed to the control page.
    </p>
    <p>
This page should work in Firefox and Chrome on the desktop computers, and on
Firefox, Chrome, and the Android browser on Android phones. Safari doesn't
support the webcam API on desktop or phone, so it will not work.
    </p>
</div>
</body>
</html>
